iT邦幫忙

2022 iThome 鐵人賽

DAY 10
1
Modern Web

這些那些你可能不知道我不知道的Web技術細節系列 第 10

你可能不知道的即時更新方案:WebSocket

  • 分享至 

  • xImage
  •  

WebSocket

image alt

WebSocket進一步解決了Long Polling會遇到的兩個問題:

  1. 取得Response後,需要在建立一次Reqeust。
  2. 僅能夠單向傳輸更新資訊。

不過WebSocket並不是超文本傳輸協定(HyperText Transfer Protocol,HTTP),但確實由HTTP開始的。因此首先是在瀏覽器發起Request之後,要進行協議的切換。Server會回傳切換資訊。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: gx+UXBfX3qJcxachxkN/n8/3+WQ=
Sec-WebSocket-Extensions: permessage-deflate
Date: Sun, 04 Sep 2022 10:36:52 GMT

在這之後就可以進行雙向傳輸,當然以可以用於更新畫面資料。

優點

  1. 雙向傳輸
  2. 連線可以重複使用

缺點

實現複雜。不是所有瀏覽器都支援,不過現在主流瀏覽器基本支援。對於伺服器也有一定要求,在我經驗上許多免費服務器是無法使用相關技術的。

Lab

前端畫面

<!-- www-data/index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8"/>
    <title>即時更新內容 - Websocket</title>
  </head>
  <body>
    <h1 id="content"></h1>
  </body>
  <script defer type="module">
   /***** 後續補充 ******/
  </script>
</html>

在畫面上我們仍然有一個會更新內容的位置--#content

實現簡單Heatbeat

const DEFAULT_TIMEOUT = 30000 /*ms*/;
const HEATBEAT_INTVAL = 5000 /*ms*/;

let ws = new WebSocket(`ws://${window.location.host}/ws`)
let timer = null;
let timeout = DEFAULT_TIMEOUT;
let handlerMap = {};

const DEFAULT_HANDLER = (type, data) => {
  console.error(`Can't handle ${type} event. data: `, data);
}

ws.addEventListener('open', async (event) => {
  console.log("connnect to websocket...");
  timer = setInterval(sendHeartBeat, HEATBEAT_INTVAL);
});


function sendHeartBeat() {
  timeout -= HEATBEAT_INTVAL;
  if(timeout <= 0) {
    console.log("heatbeat timetout. closing websocket.");
    ws.close();
    return;
  }

  ws.send(JSON.stringify({
    type: "HEATBEAT",
    data: "SYN",
  }));
}


ws.addEventListener('close', (event) => {
  console.log("close websocket...");
  clearInterval(timer);
});

在建立WebSocket連線以後(ws),每隔一段時間(HEATBEAT_INTVAL,5秒)去檢查一次連線是否依然正常。Server需要在限定時間(DEFAULT_TIMEOUT,30秒)回應相對應訊息。

若Server未在限定時間(DEFAULT_TIMEOUT,30秒)回應相對應訊息,表示連線出現問題,便斷開連線。

如果Server有回應的話,就重置timeout

handlerMap["HEATBEAT"] = (type, data) => {
  if(data === "ACK") {
    console.debug('heartbeat');
    timeout =  DEFAULT_TIMEOUT;
  }
}

handlerMap 是簡單用於後續事件處理的註冊表。

即時更新內容

同樣在建立連線後,需要向服務器註冊訂閱content.txt的改變通知訊息。

const contentEl = document.querySelector('#content');
let handlerMap = {};

ws.addEventListener('open', async (event) => {
  await ws.send(JSON.stringify({
    type: "SUB",
    data: 'content.txt',
  }))
});

對於Server回傳訊息,透過handlerMap註冊表簡單進行分派處理。

ws.addEventListener('message', (event) => {
  let { type, data } = JSON.parse(event.data);
  const handler = handlerMap[type] ?? DEFAULT_HANDLER;
  handler(type, data);
});

最重要的是接受更新訊息,並將新內容更新在畫面上:

handlerMap["UPDATE"] = (type, data) => {
  if (data['content.txt']){
    contentEl.innerText = data['content.txt'];
  }
}

後端API

這次還需要用到websockets這個套件:

pip install websockets

引入套件

from fastapi import FastAPI, WebSocket

index.html部分不變

@app.get('/index.html', response_class=HTMLResponse)
async def index():
    return FileResponse('index.html')

然後添加一段建立WebSocket連線的EndPoint

@app.websocket('/ws')
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    while True:
        data = await websocket.receive_json()
        if data['type'] == 'HEATBEAT':
            if data['data'] == 'SYN':
                await websocket.send_json({"type": "HEATBEAT", "data": "ACK"})
            continue

        if data['type'] == 'SUB':
            _file = data['data']
            message = ""
            with open(_file) as f:
                message = f.read()
            await websocket.send_json({
                "type": "UPDATE",
                "data": {
                    _file: message,
                },
            })

            tr = threading.Thread(target=subModifyFileEvent,
                                  args=(_file, websocket))
            tr.start()

主要處理兩種事件類型。先是實現Heatbeat,讓前端畫面可以確認服務狀態:

        data = await websocket.receive_json()
        if data['type'] == 'HEATBEAT':
            if data['data'] == 'SYN':
                await websocket.send_json({"type": "HEATBEAT", "data": "ACK"})
            continue

另一個是處理訂閱狀態。先在一開始回傳檔案內容

        if data['type'] == 'SUB':
            _file = data['data']
            message = ""
            with open(_file) as f:
                message = f.read()
            await websocket.send_json({
                "type": "UPDATE",
                "data": {
                    _file: message,
                },
            })

            tr = threading.Thread(target=subModifyFileEvent,
                                  args=(_file, websocket))
            tr.start()

爲了避免阻塞,建立一個Thread去監看檔案是否被修改過。這個新建立的Thread當檔案被修改過,便會回傳一個更新事件。
監看檔案的方式與Long Polling使用watchdog基本一致。

def subModifyFileEvent(file_path: str, websocket: WebSocket):
    file_stat: FileStat = { "modified": False }
    observer = Observer()
    observer.schedule(WatchDogEvent(file_stat), CONTENT_FILE, recursive=False)

    observer.start()
    try:
        while True:
            if file_stat["modified"]:
                message = ""
                with open(file_path) as f:
                    message = f.read()
                try:
                    if websocket.state == 2 or websocket.state == 3: # CLOSING or CLOSED
                        break
                    websocket.send_json({
                        "type": "UPDATE",
                        "data": {
                            file_path: message,
                        },
                    }).send(None)
                except StopIteration:
                    ...
                file_stat["modified"] = False
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()
    observer.stop()

這部分程式碼應該還有很多沒有考慮到的地方,只作爲說明DEMO使用。

DEMO

現在可以嘗試啓動服務器看看

uvicorn app:app

開啓瀏覽器瀏覽 http://localhost:8000/index.html

與先前不同的是,Heatbeat的行爲是雙向的。並且只使用了一個HTTP Reqeust。


結語

實務上應該會使用其他更高級包裝過的工具、套件。

SignalR: 微軟的解決方案,封裝了所有的即時網頁技術,包含支援 IE。
Socket.IO: node.js 解決方案,封裝了 polling 及 websocket。
MQTT: 適合輕量級物聯網使用,封包較小可以支援大量的 client。
Service Worker: 離線推播,獨立 Thread 無法操作 dom,透過 PushManager 可以使用推播。^7

這些套件在WebSocket不可用的時候,會自動退化使用PollingLong PollingServer Send Event等方式。並且提供更多高級抽象的能力,寫起來更爲方便。

但基本上還是會建議好好了解一下WebSocket本身。

參考資料

本文同時發表於我的隨筆


上一篇
你可能不知道的即時更新方案:Server Send Event
下一篇
你可能不知道的即時更新方案:multipart/x-mixed-replace
系列文
這些那些你可能不知道我不知道的Web技術細節33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言